#!/usr/bin/env node
'use strict';

/**
 * Panasonic / PJLink Multi-Projektor-Dashboard.
 *
 * Start:
 *   node panasonic_pjlink_web_monitor.js
 *
 * Danach im Browser:
 *   http://localhost:3000
 *
 * Optional per ENV:
 *   PORT=3000 node panasonic_pjlink_web_monitor.js
 *
 * Dieses Script enthält:
 *   1) HTTP-Server mit Web-Dashboard
 *   2) Backend für PJLink-Kommunikation via TCP Port 4352
 *
 * Features:
 * - mehrere Projektoren gleichzeitig überwachen
 * - Konfiguration serverseitig aus config.txt
 * - manueller Refresh oder Auto-Refresh
 * - möglichst modellunabhängig via PJLink
 * - liest Power, Eingang, Lampenstunden, Geräteinfos, Fehlerstatus
 * - unterstützt PJLink ohne Auth, PJLink v1 (MD5), PJLink v2 Probe/Fallback
 */

const http = require('http');
const net = require('net');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');

const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const CONFIG_FILE = path.join(__dirname, 'config.txt');
const DEFAULT_PJLINK_PORT = 4352;
const DEFAULT_TIMEOUT_MS = 6000;

function md5(text) {
  return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}

function sha256(text) {
  return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}

function randomHex(bytes) {
  return crypto.randomBytes(bytes).toString('hex');
}

function xorHexStrings(hexA, hexB) {
  const a = Buffer.from(hexA, 'hex');
  const b = Buffer.from(hexB, 'hex');
  if (a.length !== b.length) {
    throw new Error('XOR benötigt Hex-Strings gleicher Länge.');
  }
  const out = Buffer.alloc(a.length);
  for (let i = 0; i < a.length; i += 1) out[i] = a[i] ^ b[i];
  return out.toString('hex');
}

function connectTcp(host, port, timeoutMs = DEFAULT_TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host, port });
    const timer = setTimeout(() => {
      socket.destroy(new Error(`Timeout beim Verbinden zu ${host}:${port}`));
    }, timeoutMs);

    socket.once('connect', () => {
      clearTimeout(timer);
      resolve(socket);
    });

    socket.once('error', (err) => {
      clearTimeout(timer);
      reject(err);
    });
  });
}

function readOneLine(socket, timeoutMs = DEFAULT_TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    let settled = false;
    let buffer = '';

    const cleanup = () => {
      socket.off('data', onData);
      socket.off('error', onError);
      socket.off('close', onClose);
      clearTimeout(timer);
    };

    const finish = (fn, value) => {
      if (settled) return;
      settled = true;
      cleanup();
      fn(value);
    };

    const onData = (chunk) => {
      buffer += chunk.toString('utf8');
      const idx = buffer.indexOf('\r');
      if (idx >= 0) finish(resolve, buffer.slice(0, idx));
    };

    const onError = (err) => finish(reject, err);
    const onClose = () => finish(reject, new Error('Verbindung wurde geschlossen.'));

    const timer = setTimeout(() => {
      finish(reject, new Error('Timeout beim Warten auf Antwort.'));
    }, timeoutMs);

    socket.on('data', onData);
    socket.once('error', onError);
    socket.once('close', onClose);
  });
}

async function withSocket(host, port, timeoutMs, fn) {
  const socket = await connectTcp(host, port, timeoutMs);
  try {
    return await fn(socket);
  } finally {
    socket.destroy();
  }
}

async function sendAndReadLine(socket, payload, timeoutMs = DEFAULT_TIMEOUT_MS) {
  socket.write(payload, 'utf8');
  return readOneLine(socket, timeoutMs);
}

async function createAuthenticatedCommandSender({ host, port, password, timeoutMs }) {
  const greeting = await withSocket(host, port, timeoutMs, async (socket) => {
    return readOneLine(socket, timeoutMs);
  });

  if (greeting === 'PJLINK 0') {
    return async (command) => {
      return withSocket(host, port, timeoutMs, async (socket) => {
        const hello = await readOneLine(socket, timeoutMs);
        if (hello !== 'PJLINK 0') {
          throw new Error(`Unerwarteter PJLink-Handshake: ${hello}`);
        }
        return sendAndReadLine(socket, command, timeoutMs);
      });
    };
  }

  const legacyMatch = greeting.match(/^PJLINK 1 ([0-9A-Fa-f]{8})$/);
  if (!legacyMatch) {
    throw new Error(`Unbekannter PJLink-Handshake: ${greeting}`);
  }

  if (!password) {
    return async () => {
      throw new Error('Der Projektor verlangt ein PJLink-Passwort.');
    };
  }

  try {
    const senderV2 = await tryBuildV2Sender({ host, port, password, timeoutMs });
    if (senderV2) return senderV2;
  } catch {
    // fallback to v1
  }

  return async (command) => {
    return withSocket(host, port, timeoutMs, async (socket) => {
      const hello = await readOneLine(socket, timeoutMs);
      const match = hello.match(/^PJLINK 1 ([0-9A-Fa-f]{8})$/);
      if (!match) {
        throw new Error(`Unerwarteter PJLink-v1-Handshake: ${hello}`);
      }
      const auth = md5(match[1] + password);
      return sendAndReadLine(socket, auth + command, timeoutMs);
    });
  };
}

async function tryBuildV2Sender({ host, port, password, timeoutMs }) {
  const probeResult = await withSocket(host, port, timeoutMs, async (socket) => {
    const hello = await readOneLine(socket, timeoutMs);
    const match = hello.match(/^PJLINK 1 ([0-9A-Fa-f]{8})$/);
    if (!match) return null;

    socket.write('PJLINK 2\r', 'utf8');
    const reply = await readOneLine(socket, timeoutMs);
    const v2 = reply.match(/^PJLINK 2 ([0-9A-Fa-f]{32})$/);
    if (!v2) return null;
    return { projectorRandomHex: v2[1].toLowerCase() };
  });

  if (!probeResult) return null;

  return async (command) => {
    return withSocket(host, port, timeoutMs, async (socket) => {
      const hello = await readOneLine(socket, timeoutMs);
      const legacy = hello.match(/^PJLINK 1 ([0-9A-Fa-f]{8})$/);
      if (!legacy) {
        throw new Error(`Unerwarteter PJLink-v2-Handshake: ${hello}`);
      }

      socket.write('PJLINK 2\r', 'utf8');
      const v2Reply = await readOneLine(socket, timeoutMs);
      const v2 = v2Reply.match(/^PJLINK 2 ([0-9A-Fa-f]{32})$/);
      if (!v2) {
        throw new Error(`PJLink-v2 nicht verfügbar: ${v2Reply}`);
      }

      const projectorRandomHex = v2[1].toLowerCase();
      const controllerRandomHex = randomHex(16);
      const xorHex = xorHexStrings(projectorRandomHex, controllerRandomHex);
      const hashHex = sha256(xorHex + password);
      const payload = controllerRandomHex + hashHex + command;
      return sendAndReadLine(socket, payload, timeoutMs);
    });
  };
}

function normalizePjlinkResponse(line, commandName) {
  const prefix1 = `%1${commandName}=`;
  const prefix2 = `%2${commandName}=`;
  if (line.startsWith(prefix1)) return line.slice(prefix1.length);
  if (line.startsWith(prefix2)) return line.slice(prefix2.length);
  if (line === 'PJLINK ERRA') {
    throw new Error('PJLink-Authentifizierung fehlgeschlagen (Passwort falsch).');
  }
  throw new Error(`Unerwartete Antwort auf ${commandName}: ${line}`);
}

function mapPowerStatus(code) {
  switch (code) {
    case '0': return 'Standby';
    case '1': return 'Ein';
    case '2': return 'Abkühlen';
    case '3': return 'Aufwärmen';
    default: return `Unbekannt (${code})`;
  }
}

function parseLampResponse(value) {
  if (value === 'ERR1') {
    return { supported: false, reason: 'Projektor hat keine Lampe oder meldet keine Lampenstunden.' };
  }
  if (value === 'ERR3') {
    return { supported: false, reason: 'Lampenstatus aktuell nicht verfügbar.' };
  }
  if (value === 'ERR4') {
    return { supported: false, reason: 'Projektorfehler beim Auslesen der Lampenstunden.' };
  }

  const parts = value.trim().split(/\s+/).filter(Boolean);
  const lamps = [];
  for (let i = 0; i < parts.length; i += 2) {
    const hours = Number(parts[i]);
    const onFlag = parts[i + 1];
    if (!Number.isFinite(hours) || (onFlag !== '0' && onFlag !== '1')) continue;
    lamps.push({ index: lamps.length + 1, hours, isOn: onFlag === '1' });
  }

  return {
    supported: lamps.length > 0,
    lamps,
    totalHours: lamps.reduce((sum, l) => sum + l.hours, 0),
    maxHours: lamps.reduce((max, l) => Math.max(max, l.hours), 0),
  };
}

function parseErrorStatus(value) {
  if (value === 'ERR3') return { unavailable: true, text: 'Fehlerstatus momentan nicht verfügbar.' };
  if (value === 'ERR4') return { unavailable: true, text: 'Projektorfehler beim Auslesen des Fehlerstatus.' };
  const chars = value.trim().split('');
  if (chars.length < 6) return { unavailable: true, text: `Unerwarteter Fehlerstatus: ${value}` };
  const map = ['Fan', 'Lamp', 'Temperature', 'Cover', 'Filter', 'Other'];
  const severity = (digit) => (digit === '0' ? 'OK' : digit === '1' ? 'Warnung' : digit === '2' ? 'Fehler' : `? (${digit})`);
  return { unavailable: false, items: map.map((name, i) => ({ name, status: severity(chars[i]) })) };
}

async function queryProjector({ host, port, password, timeoutMs, label }) {
  const sendCommand = await createAuthenticatedCommandSender({ host, port, password, timeoutMs });

  async function ask(commandName, pjCommand) {
    const line = await sendCommand(pjCommand);
    return normalizePjlinkResponse(line, commandName);
  }

  const [powerRaw, lampRaw, errorsRaw, nameRaw, manufacturerRaw, productRaw, infoRaw, classRaw, inputRaw] = await Promise.allSettled([
    ask('POWR', '%1POWR ?\r'),
    ask('LAMP', '%1LAMP ?\r'),
    ask('ERST', '%1ERST ?\r'),
    ask('NAME', '%1NAME ?\r'),
    ask('INF1', '%1INF1 ?\r'),
    ask('INF2', '%1INF2 ?\r'),
    ask('INFO', '%1INFO ?\r'),
    ask('CLSS', '%1CLSS ?\r'),
    ask('INPT', '%1INPT ?\r'),
  ]);

  const safe = (result) => result.status === 'fulfilled' ? result.value : null;
  const err = (result) => result.status === 'rejected' ? result.reason?.message || String(result.reason) : null;

  return {
    ok: true,
    label: label || host,
    host,
    port,
    queriedAt: new Date().toISOString(),
    powerRaw: safe(powerRaw),
    powerStatus: safe(powerRaw) ? mapPowerStatus(safe(powerRaw)) : null,
    inputRaw: safe(inputRaw),
    lamp: safe(lampRaw) ? parseLampResponse(safe(lampRaw)) : null,
    errors: safe(errorsRaw) ? parseErrorStatus(safe(errorsRaw)) : null,
    identity: {
      name: safe(nameRaw),
      manufacturer: safe(manufacturerRaw),
      product: safe(productRaw),
      info: safe(infoRaw),
      pjlinkClass: safe(classRaw),
    },
    diagnostics: {
      power: err(powerRaw),
      lamp: err(lampRaw),
      errors: err(errorsRaw),
      name: err(nameRaw),
      manufacturer: err(manufacturerRaw),
      product: err(productRaw),
      info: err(infoRaw),
      pjlinkClass: err(classRaw),
      input: err(inputRaw),
    },
  };
}

function normalizeProjectorInput(item) {
  const host = String(item?.host || '').trim();
  const label = String(item?.label || '').trim() || host;
  const port = Number(item?.port || DEFAULT_PJLINK_PORT);
  const password = typeof item?.password === 'string' ? item.password : '';
  const timeoutMs = Number(item?.timeoutMs || DEFAULT_TIMEOUT_MS);

  if (!host) throw new Error('host fehlt');
  if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`ungültiger Port bei ${label}`);
  if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) throw new Error(`ungültiger Timeout bei ${label}`);

  return { label, host, port, password, timeoutMs };
}

async function queryManyProjectors(projectors) {
  const jobs = projectors.map(async (item) => {
    try {
      const normalized = normalizeProjectorInput(item);
      return await queryProjector(normalized);
    } catch (err) {
      return {
        ok: false,
        label: item?.label || item?.host || 'Unbekannt',
        host: item?.host || null,
        port: item?.port || null,
        queriedAt: new Date().toISOString(),
        error: err?.message || String(err),
      };
    }
  });

  const results = await Promise.all(jobs);
  return {
    queriedAt: new Date().toISOString(),
    total: results.length,
    okCount: results.filter((r) => r.ok).length,
    errorCount: results.filter((r) => !r.ok).length,
    results,
  };
}

function readProjectorConfigFile() {
  if (!fs.existsSync(CONFIG_FILE)) {
    throw new Error(`Konfigurationsdatei nicht gefunden: ${CONFIG_FILE}`);
  }

  const raw = fs.readFileSync(CONFIG_FILE, 'utf8').trim();
  if (!raw) return [];

  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (err) {
    throw new Error(`config.txt enthält ungültiges JSON: ${err.message}`);
  }

  if (!Array.isArray(parsed)) {
    throw new Error('config.txt muss ein JSON-Array enthalten.');
  }

  return parsed.map((item) => normalizeProjectorInput(item));
}

function getProjectorConfigForClient() {
  return readProjectorConfigFile().map((item) => ({
    label: item.label,
    host: item.host,
    port: item.port,
    timeoutMs: item.timeoutMs,
  }));
}

function sendJson(res, statusCode, data) {
  const body = JSON.stringify(data, null, 2);
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(body),
    'Cache-Control': 'no-store',
  });
  res.end(body);
}

function sendHtml(res, html) {
  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'no-store',
  });
  res.end(html);
}

function getHtml() {
  return `<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>PJLink Multi-Projektor Dashboard</title>
  <style>
    :root {
      color-scheme: light dark;
      --bg: #0b1020;
      --bg2: #111933;
      --panel: rgba(17, 25, 51, .85);
      --panel2: rgba(255,255,255,.04);
      --text: #ecf1ff;
      --muted: #a5b3d8;
      --line: rgba(255,255,255,.1);
      --ok: #7dd7a1;
      --warn: #ffd166;
      --err: #ff8b8b;
      --blue: #7aa2ff;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      background: linear-gradient(180deg, var(--bg), var(--bg2));
      color: var(--text);
    }
    .wrap { max-width: 1450px; margin: 0 auto; padding: 24px; }
    h1, h2, h3 { margin: 0; }
    p { color: var(--muted); }
    .panel {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 20px;
      padding: 18px;
      box-shadow: 0 16px 40px rgba(0,0,0,.22);
      margin-top: 18px;
      backdrop-filter: blur(10px);
    }
    .toolbar {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
    }
    .left, .right { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
    button {
      border: 0;
      border-radius: 12px;
      padding: 11px 14px;
      cursor: pointer;
      font-weight: 700;
    }
    button.primary { background: var(--blue); color: #071128; }
    button.secondary { background: rgba(255,255,255,.08); color: var(--text); border: 1px solid var(--line); }
    input, select {
      width: 100%;
      padding: 11px 12px;
      border-radius: 12px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,.05);
      color: var(--text);
      outline: none;
    }
    label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .92rem; }
    .grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; }
    .col-4 { grid-column: span 4; }
    .col-8 { grid-column: span 8; }
    .col-12 { grid-column: span 12; }
    @media (max-width: 1100px) { .col-4,.col-8,.col-12 { grid-column: span 12; } }
    .badge {
      display: inline-block;
      padding: 4px 9px;
      border-radius: 999px;
      border: 1px solid var(--line);
      color: var(--muted);
      font-size: .82rem;
    }
    .stats { display: grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap: 12px; }
    @media (max-width: 1100px) { .stats { grid-template-columns: repeat(4, minmax(0,1fr)); } }
    @media (max-width: 700px) { .stats { grid-template-columns: repeat(2, minmax(0,1fr)); } }
    .stat {
      background: var(--panel2);
      border: 1px solid var(--line);
      border-radius: 16px;
      padding: 16px;
    }
    .stat .k { color: var(--muted); font-size: .8rem; }
    .stat .v { font-size: 1.5rem; font-weight: 800; margin-top: 6px; }
    .cards {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
      gap: 16px;
      margin-top: 14px;
    }
    .card {
      background: var(--panel2);
      border: 1px solid var(--line);
      border-radius: 18px;
      padding: 16px;
    }
    .cardHead {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      align-items: flex-start;
      margin-bottom: 12px;
    }
    .title { font-size: 1.12rem; font-weight: 800; }
    .sub { color: var(--muted); font-size: .92rem; margin-top: 4px; }
    .ok { color: var(--ok); }
    .warn { color: var(--warn); }
    .err { color: var(--err); }
    .trafficWrap {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-top: 12px;
      padding: 10px 12px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,.03);
      border-radius: 14px;
    }
    .trafficLight {
      display: inline-flex;
      align-items: center;
      gap: 8px;
    }
    .light {
      width: 14px;
      height: 14px;
      border-radius: 999px;
      background: rgba(255,255,255,.12);
      border: 1px solid rgba(255,255,255,.18);
      box-shadow: inset 0 0 0 1px rgba(0,0,0,.1);
    }
    .light.active.green { background: #55d88a; box-shadow: 0 0 12px rgba(85,216,138,.65); }
    .light.active.yellow { background: #ffd166; box-shadow: 0 0 12px rgba(255,209,102,.65); }
    .light.active.red { background: #ff7373; box-shadow: 0 0 12px rgba(255,115,115,.65); }
    .trafficLabel { font-weight: 700; }
    .kv {
      display: grid;
      grid-template-columns: 130px 1fr;
      gap: 8px 10px;
      font-size: .95rem;
      margin-top: 10px;
    }
    .kv .k { color: var(--muted); }
    .miniTable { width: 100%; border-collapse: collapse; margin-top: 12px; }
    .miniTable td, .miniTable th { border-bottom: 1px solid var(--line); padding: 8px 4px; text-align: left; }
    details { margin-top: 12px; }
    summary { cursor: pointer; color: var(--muted); }
    pre {
      white-space: pre-wrap;
      word-break: break-word;
      border-radius: 12px;
      padding: 12px;
      background: rgba(0,0,0,.22);
      border: 1px solid var(--line);
      overflow: auto;
    }
    .hidden { display: none; }
    .hint { font-size: .92rem; color: var(--muted); }
  </style>
</head>
<body>
  <div class="wrap">
    <h1>PJLink Multi-Projektor Dashboard</h1>
    <p>Überwacht mehrere Panasonic-Projektoren parallel über PJLink. Die Projektorliste wird serverseitig aus einer <strong>config.txt</strong> im gleichen Ordner geladen.</p>

    <div class="panel">
      <div class="toolbar">
        <div class="left">
          <button id="refreshBtn" class="primary">Jetzt aktualisieren</button>
          <button id="reloadConfigBtn" class="secondary">Konfiguration neu laden</button>
        </div>
        <div class="right">
          <label for="intervalSec" style="margin:0">Auto-Refresh</label>
          <select id="intervalSec" style="width:auto; min-width:110px;">
            <option value="0">Aus</option>
            <option value="15">15 s</option>
            <option value="30">30 s</option>
            <option value="60">60 s</option>
            <option value="120" selected>120 s</option>
            <option value="300">300 s</option>
          </select>
          <span class="badge" id="lastRunBadge">Noch keine Abfrage</span>
        </div>
      </div>
    </div>

    <div class="panel hidden" id="configErrorPanel">
      <h2>Konfigurationsfehler</h2>
      <p class="err" id="configErrorText">Die Konfiguration konnte nicht geladen werden.</p>
      <p class="hint">Bitte <code>config.txt</code> im gleichen Ordner wie das Script prüfen und danach „Konfiguration neu laden“ klicken.</p>
    </div>



    <div class="panel">
      <div class="stats" id="summaryStats"></div>
    </div>

    <div class="panel">
      <h2>Projektoren</h2>
      <div id="cards" class="cards"></div>
    </div>
  </div>

  <script>
    const STORAGE_INTERVAL_KEY = 'pjlink-dashboard-interval-v1';

    const configErrorPanel = document.getElementById('configErrorPanel');
    const configErrorText = document.getElementById('configErrorText');
    const intervalEl = document.getElementById('intervalSec');
    const refreshBtn = document.getElementById('refreshBtn');
    const reloadConfigBtn = document.getElementById('reloadConfigBtn');
    const summaryStats = document.getElementById('summaryStats');
    const cards = document.getElementById('cards');
    const lastRunBadge = document.getElementById('lastRunBadge');

    let timer = null;
    let currentProjectors = [];

    function esc(value) {
      return String(value ?? '')
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
    }

    async function loadServerConfig() {
      const res = await fetch('/api/config');
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || 'Konfiguration konnte nicht geladen werden');

      currentProjectors = Array.isArray(data.projectors) ? data.projectors : [];
      configErrorPanel.classList.add('hidden');
      configErrorText.textContent = '';
      lastRunBadge.textContent = 'Konfiguration geladen';

      const storedInterval = localStorage.getItem(STORAGE_INTERVAL_KEY);
      if (storedInterval && [...intervalEl.options].some((o) => o.value === storedInterval)) {
        intervalEl.value = storedInterval;
      }
    }

    function saveIntervalOnly() {
      localStorage.setItem(STORAGE_INTERVAL_KEY, intervalEl.value);
      setupTimer();
    }

    function statusClass(status) {
      const s = String(status || '').toLowerCase();
      if (s.includes('fehler')) return 'err';
      if (s.includes('warn')) return 'warn';
      if (s.includes('ein')) return 'ok';
      return '';
    }

    function trafficLightState(r) {
      if (!r || !r.ok) {
        return { cls: 'red', label: 'Offline / Fehler' };
      }

      if (r.errors?.unavailable) {
        return { cls: 'yellow', label: 'Fehlerstatus unbekannt' };
      }

      const items = r.errors?.items || [];
      if (items.some((x) => x.status === 'Fehler')) {
        return { cls: 'red', label: 'Störung' };
      }
      if (items.some((x) => x.status === 'Warnung')) {
        return { cls: 'yellow', label: 'Warnung' };
      }

      if (r.powerStatus === 'Ein') {
        return { cls: 'green', label: 'Betrieb OK' };
      }
      if (r.powerStatus === 'Standby') {
        return { cls: 'green', label: 'Standby OK' };
      }
      if (r.powerStatus === 'Aufwärmen' || r.powerStatus === 'Abkühlen') {
        return { cls: 'yellow', label: 'Übergang' };
      }

      return { cls: 'yellow', label: 'Unklar' };
    }

    function lampSummary(r) {
      const lamp = r?.lamp;
      if (!lamp) return '—';
      if (!lamp.supported) return lamp.reason || 'Nicht verfügbar';
      if (!lamp.lamps?.length) return 'Keine Daten';
      return lamp.lamps.map((l) => 'L' + l.index + ': ' + l.hours + ' h').join(' · ');
    }

    function errorSummary(r) {
      if (!r?.errors) return '—';
      if (r.errors.unavailable) return r.errors.text || 'Nicht verfügbar';
      const problems = (r.errors.items || []).filter((x) => x.status !== 'OK');
      if (!problems.length) return 'Keine Fehler';
      return problems.map((p) => p.name + ': ' + p.status).join(' · ');
    }

    function renderSummary(data) {
      const total = data?.total || 0;
      const okCount = data?.okCount || 0;
      const errCount = data?.errorCount || 0;
      const running = (data?.results || []).filter((r) => r.ok && r.powerStatus === 'Ein').length;
      const greenCount = (data?.results || []).filter((r) => trafficLightState(r).cls === 'green').length;
      const yellowCount = (data?.results || []).filter((r) => trafficLightState(r).cls === 'yellow').length;
      const redCount = (data?.results || []).filter((r) => trafficLightState(r).cls === 'red').length;

      summaryStats.innerHTML = [
        ['Gesamt', total],
        ['Erreichbar', okCount],
        ['Aktiv', running],
        ['Fehler', errCount],
        ['Grün', greenCount],
        ['Gelb', yellowCount],
        ['Rot', redCount],
      ].map(([k, v]) =>
        '<div class="stat"><div class="k">' + esc(k) + '</div><div class="v">' + esc(v) + '</div></div>'
      ).join('');
    }

    function renderCards(data) {
      const results = data?.results || [];
      if (!results.length) {
        cards.innerHTML = '<div class="card"><div class="title">Keine Projektoren konfiguriert</div><div class="sub">Bitte <code>config.txt</code> prüfen.</div></div>';
        return;
      }

      cards.innerHTML = results.map((r) => {
        const traffic = trafficLightState(r);

        if (!r.ok) {
          return '' +
            '<div class="card">' +
              '<div class="cardHead">' +
                '<div><div class="title">' + esc(r.label || r.host || 'Unbekannt') + '</div><div class="sub">' + esc(r.host || '—') + (r.port ? ':' + esc(r.port) : '') + '</div></div>' +
                '<span class="badge err">Fehler</span>' +
              '</div>' +
              '<div class="trafficWrap">' +
                '<div class="trafficLight" aria-label="Ampelstatus ' + esc(traffic.label) + '">' +
                  '<span class="light ' + (traffic.cls === 'red' ? 'active red' : '') + '"></span>' +
                  '<span class="light ' + (traffic.cls === 'yellow' ? 'active yellow' : '') + '"></span>' +
                  '<span class="light ' + (traffic.cls === 'green' ? 'active green' : '') + '"></span>' +
                '</div>' +
                '<div class="trafficLabel">' + esc(traffic.label) + '</div>' +
              '</div>' +
              '<div class="kv">' +
                '<div class="k">Meldung</div><div class="err">' + esc(r.error || 'Unbekannter Fehler') + '</div>' +
                '<div class="k">Zeit</div><div>' + esc(r.queriedAt || '—') + '</div>' +
              '</div>' +
            '</div>';
        }

        const identity = r.identity || {};
        const lampRows = (r.lamp?.supported && r.lamp?.lamps?.length)
          ? r.lamp.lamps.map((l) => '<tr><td>Lampe ' + esc(l.index) + '</td><td>' + esc(l.hours) + ' h</td><td class="' + (l.isOn ? 'ok' : '') + '">' + (l.isOn ? 'Ein' : 'Aus') + '</td></tr>').join('')
          : '<tr><td colspan="3">Keine Lampendaten</td></tr>';

        const errRows = r.errors?.unavailable
          ? '<tr><td colspan="2">' + esc(r.errors.text || 'Nicht verfügbar') + '</td></tr>'
          : ((r.errors?.items || []).map((item) => '<tr><td>' + esc(item.name) + '</td><td class="' + esc(statusClass(item.status)) + '">' + esc(item.status) + '</td></tr>').join('') || '<tr><td colspan="2">Keine Fehlerdaten</td></tr>');

        return '' +
          '<div class="card">' +
            '<div class="cardHead">' +
              '<div>' +
                '<div class="title">' + esc(r.label || identity.name || r.host) + '</div>' +
                '<div class="sub">' + esc(r.host) + ':' + esc(r.port) + ' · ' + esc(identity.product || identity.name || 'Unbekanntes Modell') + '</div>' +
              '</div>' +
              '<span class="badge ' + esc(statusClass(r.powerStatus)) + '">' + esc(r.powerStatus || '—') + '</span>' +
            '</div>' +
            '<div class="trafficWrap">' +
              '<div class="trafficLight" aria-label="Ampelstatus ' + esc(traffic.label) + '">' +
                '<span class="light ' + (traffic.cls === 'red' ? 'active red' : '') + '"></span>' +
                '<span class="light ' + (traffic.cls === 'yellow' ? 'active yellow' : '') + '"></span>' +
                '<span class="light ' + (traffic.cls === 'green' ? 'active green' : '') + '"></span>' +
              '</div>' +
              '<div class="trafficLabel">' + esc(traffic.label) + '</div>' +
            '</div>' +
            '<div class="kv">' +
              '<div class="k">Hersteller</div><div>' + esc(identity.manufacturer || '—') + '</div>' +
              '<div class="k">Modell</div><div>' + esc(identity.product || '—') + '</div>' +
              '<div class="k">Gerätename</div><div>' + esc(identity.name || '—') + '</div>' +
              '<div class="k">Eingang</div><div>' + esc(r.inputRaw || '—') + '</div>' +
              '<div class="k">Lampen</div><div>' + esc(lampSummary(r)) + '</div>' +
              '<div class="k">Fehler</div><div class="' + esc(errorSummary(r) === 'Keine Fehler' ? 'ok' : (errorSummary(r).includes('Warnung') ? 'warn' : '')) + '">' + esc(errorSummary(r)) + '</div>' +
              '<div class="k">Abfrage</div><div>' + esc(r.queriedAt || '—') + '</div>' +
            '</div>' +
            '<details>' +
              '<summary>Details</summary>' +
              '<table class="miniTable"><thead><tr><th>Lampe</th><th>Runtime</th><th>Status</th></tr></thead><tbody>' + lampRows + '</tbody></table>' +
              '<table class="miniTable"><thead><tr><th>Bereich</th><th>Status</th></tr></thead><tbody>' + errRows + '</tbody></table>' +
              '<div style="height:10px"></div>' +
              '<pre>' + esc(JSON.stringify(r.diagnostics || {}, null, 2)) + '</pre>' +
            '</details>' +
          '</div>';
      }).join('');
    }

    async function refreshDashboard() {
      const parsed = currentProjectors;

      if (!Array.isArray(parsed) || parsed.length === 0) {
        renderSummary({ total: 0, okCount: 0, errorCount: 0, results: [] });
        cards.innerHTML = '<div class="card"><div class="title">Keine Projektoren konfiguriert</div><div class="sub">Bitte <code>config.txt</code> prüfen.</div></div>';
        return;
      }

      refreshBtn.disabled = true;
      refreshBtn.textContent = 'Aktualisiere …';
      lastRunBadge.textContent = 'Abfrage läuft …';

      try {
        const res = await fetch('/api/query-many', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ projectors: parsed }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Fehler bei der Abfrage');
        renderSummary(data);
        renderCards(data);
        configErrorPanel.classList.add('hidden');
        lastRunBadge.textContent = 'Letzte Abfrage: ' + new Date().toLocaleString('de-CH');
      } catch (err) {
        const message = err.message || String(err);
        renderSummary({ total: 0, okCount: 0, errorCount: 1, results: [] });
        cards.innerHTML = '<div class="card"><div class="title err">Dashboard-Fehler</div><div class="sub">' + esc(message) + '</div></div>';
        if (message.toLowerCase().includes('config.txt') || message.toLowerCase().includes('konfiguration')) {
          configErrorText.textContent = message;
          configErrorPanel.classList.remove('hidden');
        }
        lastRunBadge.textContent = 'Fehler bei letzter Abfrage';
      } finally {
        refreshBtn.disabled = false;
        refreshBtn.textContent = 'Jetzt aktualisieren';
      }
    }

    function setupTimer() {
      if (timer) {
        clearInterval(timer);
        timer = null;
      }
      const sec = Number(intervalEl.value || '0');
      if (sec > 0) {
        timer = setInterval(refreshDashboard, sec * 1000);
      }
    }

    refreshBtn.addEventListener('click', refreshDashboard);

    reloadConfigBtn.addEventListener('click', async () => {
      try {
        await loadServerConfig();
        await refreshDashboard();
      } catch (err) {
        const message = err.message || String(err);
        configErrorText.textContent = message;
        configErrorPanel.classList.remove('hidden');
        alert(message);
      }
    });

    intervalEl.addEventListener('change', () => {
      saveIntervalOnly();
    });

    renderSummary({ total: 0, okCount: 0, errorCount: 0, results: [] });
    renderCards({ results: [] });

    loadServerConfig()
      .then(() => refreshDashboard())
      .catch((err) => {
        const message = err.message || String(err);
        configErrorText.textContent = message;
        configErrorPanel.classList.remove('hidden');
        renderSummary({ total: 0, okCount: 0, errorCount: 0, results: [] });
        cards.innerHTML = '<div class="card"><div class="title err">Konfiguration konnte nicht geladen werden</div><div class="sub">' + esc(message) + '</div></div>';
      });

    setupTimer();
  </script>
</body>
</html>`;
}

async function readJsonBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', (chunk) => {
      body += chunk.toString('utf8');
      if (body.length > 2_000_000) {
        reject(new Error('Request-Body zu groß.'));
        req.destroy();
      }
    });
    req.on('end', () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch {
        reject(new Error('Ungültiges JSON im Request-Body.'));
      }
    });
    req.on('error', reject);
  });
}

const server = http.createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);

    if (req.method === 'GET' && url.pathname === '/') {
      sendHtml(res, getHtml());
      return;
    }

    if (req.method === 'POST' && url.pathname === '/api/query') {
      const body = await readJsonBody(req);
      const host = String(body.host || '').trim();
      const port = Number(body.port || DEFAULT_PJLINK_PORT);
      const password = typeof body.password === 'string' ? body.password : '';
      const timeoutMs = Number(body.timeoutMs || DEFAULT_TIMEOUT_MS);
      const label = String(body.label || '').trim();

      if (!host) {
        sendJson(res, 400, { error: 'host ist erforderlich.' });
        return;
      }

      const result = await queryProjector({ host, port, password, timeoutMs, label });
      sendJson(res, 200, result);
      return;
    }

    if (req.method === 'GET' && url.pathname === '/api/config') {
      const projectors = getProjectorConfigForClient();
      sendJson(res, 200, { projectors, configFile: CONFIG_FILE });
      return;
    }

    if (req.method === 'POST' && url.pathname === '/api/query-many') {
      const body = await readJsonBody(req);
      const projectors = Array.isArray(body.projectors) ? body.projectors : null;
      if (!projectors) {
        sendJson(res, 400, { error: 'projectors muss ein Array sein.' });
        return;
      }

      const configByKey = new Map(readProjectorConfigFile().map((item) => [`${item.host}:${item.port}`, item]));
      const mergedProjectors = projectors.map((item) => {
        const key = `${item.host}:${item.port}`;
        const cfg = configByKey.get(key);
        if (!cfg) {
          return item;
        }
        return {
          label: item.label || cfg.label,
          host: cfg.host,
          port: cfg.port,
          password: cfg.password,
          timeoutMs: item.timeoutMs || cfg.timeoutMs,
        };
      });

      const result = await queryManyProjectors(mergedProjectors);
      sendJson(res, 200, result);
      return;
    }

    sendJson(res, 404, { error: 'Not found' });
  } catch (err) {
    sendJson(res, 500, { error: err?.message || 'Interner Serverfehler' });
  }
});

server.listen(PORT, () => {
  console.log(`PJLink Multi-Dashboard läuft auf http://localhost:${PORT}`);
});
